1. Исходные данные¶
Датасет TinyImageNet. 120000 изображений размера 64x64, разделенные на train (100000), val и test (по 10000).
Изображения поровну распределены по 200 классам.
Требуется построить классификатор изображений из датасета на основе свёрточной нейронной сети с максимально возможной точностью - accuracy@1.
2. Работа с источниками.¶
В сети много информации по подходам к датасету, например:
- kaggle соревнование 2019 года - лучшая точность 0.86, хотя второе место 0.46, подозрительно большой отрыв
- отчёт из Стэнфорда 2017 года - Inception-Resnet достиг 43% error-rate. Также рассмотрены модели resnet и vgg-16/19.
- статья про использование ViT для классификации Tiny Imagenet 2022 года - достигнута точность 91.35%. Но, т.к.требуется использовать свёрточные нейросети, эта модель останется вне нашего внимания, просто примем полученную здесь точность как некоторый идеал.
В соревновании нужно побить два бейзлайна - с точностями 0.25 и 0.5, что с учётом данных выше выглядит вполне реалистичным
3. Первые эксперименты¶
Я решил работать в основном с моделями семейства resnet, т.к.они есть в библиотеке torchvision, хорошо себя показывают в многоклассовой классификации изображений, быстро сходятся, пред'являют разумные требования по памяти, и дают возможность поэкспериментировать как с лёгкими вариантами (resnet18) так и с вариантами потяжелее (resnet152) без перекраивания архитектуры.
Первые подходы давали точность ниже 0.4. Пробовал различные версии resnet, варьировал learning_rate - точности сходились к асимптоте чуть ниже 0.4
📁 Код отрисовки
import mlflow
from mlflow.tracking import MlflowClient
import plotly.graph_objects as go
import pandas as pd
import plotly.io as pio
pio.renderers.default = 'notebook'
def plot_mlflow_metrics(run_ids, metric_names=None, pt=0):
"""
Загружает и визуализирует метрики из MLflow.
Аргументы:
run_ids (list[str] | str): один или несколько MLflow run_id
metric_names (list[str], optional): какие метрики грузить
По умолчанию: ['val acc', 'val loss', 'train acc', 'train loss']
"""
if isinstance(run_ids, str):
run_ids = [run_ids]
client = MlflowClient()
metric_names = metric_names or ['val acc', 'val loss', 'train acc', 'train loss']
accs = [m for m in metric_names if "acc" in m]
losses = [m for m in metric_names if "loss" in m]
metrics = {}
# --- Сбор данных из MLflow ---
for ri in run_ids:
metrics[ri] = {}
for mn in metric_names:
hist = client.get_metric_history(ri, mn)
if len(hist) > 0:
df = pd.DataFrame([{"step": m.step, "value": m.value} for m in hist])
df = df.sort_values("step").reset_index(drop=True)
metrics[ri][mn] = df
# print(f"✅ Found {mn} for {ri[:8]}: {len(df)} points")
else:
print(f"⚠️ No data for {mn} in {ri[:8]}")
# --- Accuracy plot ---
fig_acc = go.Figure()
for ri, mset in metrics.items():
for mn in accs:
if mn in mset:
df = mset[mn]
fig_acc.add_trace(go.Scatter(
x=df["step"],
y=df["value"],
mode="lines+markers",
name=f"{ri[:6]} - {mn}"
))
fig_acc.update_layout(
title="Accuracy Metrics from MLflow Runs",
xaxis_title="Step",
yaxis_title="Accuracy",
template="plotly_white",
width=900,
height=600
)
# --- Loss plot ---
fig_loss = go.Figure()
for ri, mset in metrics.items():
for mn in losses:
if mn in mset:
df = mset[mn]
fig_loss.add_trace(go.Scatter(
x=df["step"],
y=df["value"],
mode="lines+markers",
name=f"{ri[:6]} - {mn}"
))
fig_loss.update_layout(
title="Loss Metrics from MLflow Runs",
xaxis_title="Step",
yaxis_title="Loss",
template="plotly_white",
width=900,
height=600
)
fig_acc.show()
fig_loss.show()
# fig_acc.write_html(f"fig_acc_{pt}.html", include_plotlyjs='cdn', full_html=False)
# fig_loss.write_html(f"fig_loss_{pt}.html", include_plotlyjs='cdn', full_html=False)
Ниже примеры кривых обучения некоторых экспериментов этого периода
plot_mlflow_metrics([
'5ce61a4f14b946f5b17b3ed59a66fa61',
'e38853ef92c343fc8169d94ed48ec931',
'f0a0156f2ba5452e84256ef0c322f39d',
'97cfce2484b949069f00d5a9d19d7c82'
], pt=3)
4. Эксперименты с нарушениями¶
Очень бросался в глаза пункт про запрет на upsampling входных данных. Я решил попробовать что это может дать. Точность значительно подскочила, пробив отметку в 0.5, то есть больше чем на 10%
Ниже примеры некоторых тренировок того времени
plot_mlflow_metrics([
'7e28894db4f54fc69859d9c228936a78',
'888ab91a770a4d53a8914ab1602b4509'
], pt=4)
Кроме того, раз уж мы зашли в запретную зону я попробовал что может дать self-supervised претренировка на train подмножестве. Схема такая: загружаем батч и применяем к нему две разных аугментации и даём в них лэйблы, не связанные с данными, но так, чтобы на одной и той же картинке был один и тот же лейбл. Дальше обучаем модель на минимизацию кросс-энтропии.
По идее первые слои должны выучивать особенности, и создающие инвариантность к аугментациям, но видимо из-за ошибок и отсутствия опыта в такой технике, улучшений ни в смысле скорости сходимости ни в смысле точности не получилось.
plot_mlflow_metrics([
'b7f3320b03ca40b68851bf0fc8ae433d',
'7ad88093477942839474ab34c65935c8'
], pt=4.1)
5. Эксперименты с модификацией архитектуры ResNet¶
После предыдущего пункта появилась идея заглянуть в архитектуру ResNet повнимательнее и понять, почему upsampling даёт такое преимущество. Появилась интуитивная гипотеза, что по причине маленького размера картинки модели нужно самые низкоуровневые признаки протащить поглубже. В ResNet же первый свёрточный слой имеет kernel size 7x7 и stride 2x2. А потом ещё идёт maxpooling, что даёт уже довольно низкое разрешение ко второму свёрточному слою.
Модификация заключается в том, что мы заменяем первый свёрточный слой на слой с kernel_size 3x3 и stride 1x1, а также убираем maxpooling. Тогда ко второму свёрточному слою разрешение не будет так сильно загрублено.
Также я поэкспериментировал с различными начальными (и постоянными) learning rate, и эмпирически нащупал те, которые дают комбо более быстрой сходимости и более высокой установившейся точности
Здесь же я стал использовать reduce on plateau планировщик, потому что в какой-то момент валидационный лосс начинал улетать в космос, благодаря планировщику удалось его удерживать
И это дало хороший прирост: модели стали сходиться к точностям в диапазоне 0.54-0.56, на чём я и остановился
plot_mlflow_metrics([
'cb71e620a06a41b5b3fd0e04adfd9b3a',
'c6a1c3a418d0492cb8376ed58cac5e4c',
'30935ecaf8484365baf57751ca4149b9',
'73ca7a48a5db4791bf0881006d413f62'
], pt=5)
Остальные (неудачные) эксперименты¶
Также были попробованы разные архитектуры из других семейств: efficientnet, mobelinet, convnext, - но до таких же значений точности как в экспериментах ResNet довести не получилось. Однако если уделить им побольше времени, то возможно что-то и получилось бы.
Ниже кривые обучения convnext small и tiny, видно сильное переобучение. Здесь использован оптимизатор AdamW и планировщик cosine annealing.
plot_mlflow_metrics([
'85f495b18a2844b494cb4ba06bc17f46',
'4a453b755ba640b98a888e6272d1b86c',
], pt=6)
6. Модификация первых слоёв и головы ResNet¶
self.model.fc = nn.Sequential(nn.Dropout(0.6), nn.Linear(self.model.fc.in_features, 200))
self.model.conv1 = nn.Conv2d(3, 64, 3, stride=1, padding=1)
self.model.maxpool = nn.Identity()
7. Аугментации¶
На протяжении всех экспериментов я использовал следующие аугментации
- RandomHorizontalFlip
- RandomVerticalFlip
- RandomErasing(scale=(0.02, 0.1), value='random')
- ColorJitter(brightness=0.4, contrast=0.1)
В первых экспериментах их отсутсвие давало быстрое переобучение, тренировочная точность сходилась к 0.9, в то время как валидационная доползала до 0.3, поэтому я добавил их, получил прирост точности и более медленное переобучение, зафиксировал и больше с ними не экспериментировал.
Выводы¶
- Данные важны, аугментации важны, архитектуры важны
- Добиться эффекта от upsampling-а можно немного поменяв первые слои в архитектуре
- Положительные эффекты от Self Supervised предобучения не ощутились
- Непонятным остался метод выбора learning rate. Поскольку он влияет и на скорость сходимости и на значение, к которому сходимся, то он очень важен, но после домашки осталось ощущение, что в каждой новой задаче нужно посвящать некоторое время только его подбору.